aboutsummaryrefslogtreecommitdiff
path: root/frontend/src/routes/services/[id]/+page.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/routes/services/[id]/+page.svelte')
-rw-r--r--frontend/src/routes/services/[id]/+page.svelte287
1 files changed, 225 insertions, 62 deletions
diff --git a/frontend/src/routes/services/[id]/+page.svelte b/frontend/src/routes/services/[id]/+page.svelte
index 36ea105..a7b8877 100644
--- a/frontend/src/routes/services/[id]/+page.svelte
+++ b/frontend/src/routes/services/[id]/+page.svelte
@@ -6,17 +6,41 @@
import type { Service, Heartbeat, ServiceStats } from '$lib/types';
let svc: Service | undefined = $state();
- let history: Heartbeat[] = $state([]);
+ let chartHistory: Heartbeat[] = $state([]);
let stats: ServiceStats | null = $state(null);
let loading = $state(true);
+
+ let tableData: Heartbeat[] = $state([]);
+ let currentPage = $state(1);
+ let totalPages = $state(1);
+ let totalItems = $state(0);
+
let expandedError: number | null = $state(null);
+ const PER_PAGE = 50;
+
+ async function loadTablePage(pageNum: number) {
+ const res = await getHistory(Number($page.params.id), pageNum, PER_PAGE);
+ tableData = res.data;
+ currentPage = res.page;
+ totalPages = res.total_pages;
+ totalItems = res.total;
+ }
+
onMount(async () => {
try {
const all = await listServices();
svc = all.find((s) => s.id === Number($page.params.id));
- history = await getHistory(Number($page.params.id));
+
+ const chartRes = await fetch(`/api/services/${$page.params.id}/history?page=1&per_page=50`);
+ if (chartRes.ok) {
+ const json = await chartRes.json();
+ chartHistory = json.data ?? json;
+ }
+
getServiceStats(Number($page.params.id)).then((s) => (stats = s)).catch(() => {});
+
+ await loadTablePage(1);
} catch {
// handle
} finally {
@@ -24,47 +48,117 @@
}
});
- $effect(() => {
- if (!svc && $page.params.id) {
- getHistory(Number($page.params.id))
- .then((h) => (history = h))
- .catch(() => {});
- }
- });
-
onMount(() => {
const unsub = subscribeHeartbeats((hb) => {
if (hb.service_id !== Number($page.params.id)) return;
- history = [...history, hb].slice(-50);
+ chartHistory = [...chartHistory, hb].slice(-50);
+ if (currentPage === 1) {
+ tableData = [hb, ...tableData].slice(0, PER_PAGE);
+ totalItems++;
+ totalPages = Math.max(1, Math.ceil(totalItems / PER_PAGE));
+ }
});
return unsub;
});
- let chartPad = { t: 8, b: 20, l: 40, r: 12 };
- let chartW = $derived(Math.max(history.length * 32, 300));
+ let containerWidth = $state(0);
+ let pad = { t: 8, b: 24, l: 44, r: 12 };
let chartH = 200;
- let plotW = $derived(chartW - chartPad.l - chartPad.r);
- let plotH = $derived(chartH - chartPad.t - chartPad.b);
- let chartMax = $derived(Math.max(...history.map((h) => h.response_time_ms), 100));
- let chartMin = $derived(0);
+ let plotW = $derived(Math.max(containerWidth - pad.l - pad.r, 0));
+ let plotH = $derived(chartH - pad.t - pad.b);
+
+ function niceNum(range: number, round: boolean): number {
+ const exp = Math.floor(Math.log10(range));
+ const frac = range / Math.pow(10, exp);
+ let nice: number;
+ if (round) {
+ if (frac <= 1.5) nice = 1;
+ else if (frac <= 3) nice = 2;
+ else if (frac <= 7) nice = 5;
+ else nice = 10;
+ } else {
+ if (frac <= 1) nice = 1;
+ else if (frac <= 2) nice = 2;
+ else if (frac <= 5) nice = 5;
+ else nice = 10;
+ }
+ return nice * Math.pow(10, exp);
+ }
+
+ let yAxis = $derived.by(() => {
+ const vals = chartHistory.map((h) => h.response_time_ms);
+ if (vals.length === 0) return { min: 0, max: 100, ticks: [{ val: 0, y: 0 }, { val: 100, y: 200 }] };
+ const rawMin = Math.min(...vals);
+ const rawMax = Math.max(...vals);
+ if (rawMax === rawMin) return { min: 0, max: Math.max(rawMax * 2, 100), ticks: [] };
+
+ const range = rawMax - rawMin;
+ const pad = Math.max(range * 0.15, 10);
+ let lo = Math.max(0, rawMin - pad);
+ let hi = rawMax + pad;
+
+ const tickStep = niceNum((hi - lo) / 4, true);
+ lo = Math.floor(lo / tickStep) * tickStep;
+ hi = Math.ceil(hi / tickStep) * tickStep;
+
+ const ticks: { val: number; y: number }[] = [];
+ for (let v = lo; v <= hi + tickStep * 0.001; v += tickStep) {
+ const y = pad.t + plotH - ((v - lo) / (hi - lo || 1)) * plotH;
+ ticks.push({ val: Math.round(v), y });
+ }
+ return { min: lo, max: hi, ticks };
+ });
+
+ let segments = $derived.by(() => {
+ const n = chartHistory.length;
+ if (n < 2) return [];
+ const { min, max } = yAxis;
+ return chartHistory.slice(0, -1).map((h, i) => {
+ const next = chartHistory[i + 1];
+ const x1 = pad.l + (i / (n - 1)) * plotW;
+ const x2 = pad.l + ((i + 1) / (n - 1)) * plotW;
+ const y1 = pad.t + plotH - ((h.response_time_ms - min) / (max - min || 1)) * plotH;
+ const y2 = pad.t + plotH - ((next.response_time_ms - min) / (max - min || 1)) * plotH;
+ return {
+ x1, y1, x2, y2,
+ color: h.is_up && next.is_up ? 'var(--green)' : 'var(--red)'
+ };
+ });
+ });
let dots = $derived(
- history.map((h, i) => {
- const x = history.length === 1
- ? chartPad.l + plotW / 2
- : chartPad.l + (i / (history.length - 1)) * plotW;
- const y = chartPad.t + plotH - ((h.response_time_ms - chartMin) / (chartMax - chartMin || 1)) * plotH;
+ chartHistory.map((h, i) => {
+ const { min, max } = yAxis;
+ const x = pad.l + (i / (Math.max(chartHistory.length - 1, 1))) * plotW;
+ const y = pad.t + plotH - ((h.response_time_ms - min) / (max - min || 1)) * plotH;
return { x, y, ...h };
})
);
- let yTicks = $derived.by(() => {
- const n = 4;
- return Array.from({ length: n + 1 }, (_, i) => {
- const val = chartMin + ((chartMax - chartMin) / n) * i;
- const y = chartPad.t + plotH - ((val - chartMin) / (chartMax - chartMin || 1)) * plotH;
- return { val: Math.round(val), y };
- });
+ let areaPath = $derived.by(() => {
+ if (chartHistory.length < 2) return '';
+ const { min, max } = yAxis;
+ const baseline = pad.t + plotH;
+ const top = dots.map((d) => `${d.x},${d.y}`).join(' L ');
+ const bottom = dots.map((d) => d.x).reverse().map((x) => `${x},${baseline}`).join(' L ');
+ return `M ${top} L ${bottom} Z`;
+ });
+
+ let pages = $derived.by(() => {
+ const p: (number | string)[] = [];
+ const total = totalPages;
+ if (total <= 7) {
+ for (let i = 1; i <= total; i++) p.push(i);
+ } else {
+ p.push(1);
+ if (currentPage > 3) p.push('...');
+ const start = Math.max(2, currentPage - 1);
+ const end = Math.min(total - 1, currentPage + 1);
+ for (let i = start; i <= end; i++) p.push(i);
+ if (currentPage < total - 2) p.push('...');
+ p.push(total);
+ }
+ return p;
});
</script>
@@ -143,39 +237,47 @@
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
Tempo de Resposta (ms)
</h2>
- <div class="relative h-48">
- {#if history.length === 0}
+ <div bind:clientWidth={containerWidth} class="relative" style="height: 200px">
+ {#if chartHistory.length === 0}
<div class="flex h-full items-center justify-center text-xs text-[var(--text-muted)]">
Sem dados ainda
</div>
- {:else if history.length === 1}
+ {:else if chartHistory.length === 1}
<div class="flex h-full flex-col items-center justify-center gap-1">
<span
class="text-3xl font-bold tabular-nums"
- style="color: {history[0].is_up ? 'var(--green)' : 'var(--red)'}"
+ style="color: {chartHistory[0].is_up ? 'var(--green)' : 'var(--red)'}"
>
- {history[0].response_time_ms}
+ {chartHistory[0].response_time_ms}
<span class="text-base font-normal text-[var(--text-secondary)]">ms</span>
</span>
<span class="text-xs text-[var(--text-muted)]">
- {history[0].is_up ? 'Online' : 'Offline'} —
- {history[0].status_code || 'timeout'}
+ {chartHistory[0].is_up ? 'Online' : 'Offline'} —
+ {chartHistory[0].status_code || 'timeout'}
</span>
</div>
{:else}
- <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none">
- {#each yTicks as tick}
+ <svg width={containerWidth} height={chartH}>
+ <defs>
+ <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
+ <stop offset="0%" stop-color="var(--green)" stop-opacity="0.15" />
+ <stop offset="100%" stop-color="var(--green)" stop-opacity="0.01" />
+ </linearGradient>
+ </defs>
+
+ <!-- grid horizontal -->
+ {#each yAxis.ticks as tick}
<line
- x1={chartPad.l}
+ x1={pad.l}
y1={tick.y}
- x2={chartW - chartPad.r}
+ x2={containerWidth - pad.r}
y2={tick.y}
stroke="var(--border-color)"
stroke-width="1"
stroke-dasharray="3,3"
/>
<text
- x={chartPad.l - 6}
+ x={pad.l - 6}
y={tick.y + 3}
text-anchor="end"
fill="var(--text-muted)"
@@ -184,38 +286,56 @@
>
{/each}
- <polyline
- points={dots.map((d) => `${d.x},${d.y}`).join(' ')}
- fill="none"
- stroke="var(--green)"
- stroke-width="2"
- stroke-linecap="round"
- stroke-linejoin="round"
- />
+ <!-- área preenchida -->
+ <path d={areaPath} fill="url(#areaGrad)" />
+ <!-- segmentos da linha -->
+ {#each segments as seg}
+ <line
+ x1={seg.x1}
+ y1={seg.y1}
+ x2={seg.x2}
+ y2={seg.y2}
+ stroke={seg.color}
+ stroke-width="2"
+ stroke-linecap="round"
+ />
+ {/each}
+
+ <!-- pontos -->
{#each dots as d}
- <circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} />
+ <circle
+ cx={d.x}
+ cy={d.y}
+ r="3"
+ fill={d.is_up ? 'var(--green)' : 'var(--red)'}
+ stroke="var(--bg-card)"
+ stroke-width="1.5"
+ />
{/each}
</svg>
+
+ <!-- eixo X: tempo -->
+ <div class="flex justify-between" style="padding-left: {pad.l}px; padding-right: {pad.r}px;">
+ <span class="text-[10px] text-[var(--text-muted)]">
+ {new Date(chartHistory[0].tested_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
+ </span>
+ <span class="text-[10px] text-[var(--text-muted)]">
+ {new Date(chartHistory[chartHistory.length - 1].tested_at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}
+ </span>
+ </div>
{/if}
</div>
- <div class="mt-2 flex justify-between text-[10px] text-[var(--text-muted)]">
- <span>{history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''}</span>
- <span
- >{history[history.length - 1]?.tested_at
- ? new Date(history[history.length - 1].tested_at).toLocaleTimeString()
- : ''}</span
- >
- </div>
</div>
- <!-- Timeline compacta -->
- <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+ <!-- Histórico paginado -->
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-3">
<h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
- Últimas Verificações
+ Histórico de Verificações
</h2>
+
<div class="flex flex-col gap-1.5">
- {#each history.slice().reverse() as h}
+ {#each tableData as h}
<div>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
@@ -256,6 +376,49 @@
</div>
{/each}
</div>
+
+ <!-- Paginação -->
+ {#if totalPages > 1}
+ <div class="mt-5 flex items-center justify-center gap-1.5">
+ <button
+ onclick={() => loadTablePage(currentPage - 1)}
+ disabled={currentPage <= 1}
+ class="rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-30"
+ style="color: var(--text-muted); {currentPage <= 1 ? '' : 'hover:bg-[var(--border-color)] hover:text-white'}"
+ >
+ Anterior
+ </button>
+
+ {#each pages as p}
+ {#if p === '...'}
+ <span class="px-1 text-xs text-[var(--text-muted)]">…</span>
+ {:else}
+ <button
+ onclick={() => loadTablePage(p as number)}
+ class="min-w-[28px] rounded-lg px-2 py-1.5 text-xs font-medium transition-all"
+ style={p === currentPage
+ ? 'background-color: var(--green); color: #000; font-weight: 700;'
+ : 'color: var(--text-muted); hover:background-color: var(--border-color); hover:color: white;'}
+ >
+ {p}
+ </button>
+ {/if}
+ {/each}
+
+ <button
+ onclick={() => loadTablePage(currentPage + 1)}
+ disabled={currentPage >= totalPages}
+ class="rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-30"
+ style="color: var(--text-muted); {currentPage >= totalPages ? '' : 'hover:bg-[var(--border-color)] hover:text-white'}"
+ >
+ Próximo
+ </button>
+ </div>
+
+ <p class="mt-2 text-center text-[10px] text-[var(--text-muted)]">
+ Página {currentPage} de {totalPages} — {totalItems} verificações no total
+ </p>
+ {/if}
</div>
</div>
{/if}